const express = require("express");
const validate = require("express-jsonschema").validate;
const bodyParser = require("body-parser");

const app = express();
app.use(express.json({
    type: function() {
        return true;
    }
}));

const admin = require("firebase-admin");

admin.initializeApp({
    credential: admin.credential.applicationDefault()
});

function isValidCurrency(input) {
    if (input === undefined) { return true; }
    return (input * 100) % 1 === 0
}

const DEPOSIT = 'DEPOSIT';
const WITHDRAWAL = 'WITHDRAWAL';
const TRANSFER = 'TRANSFER';
function isValidTransactionType(input) {
    if (input === undefined) { return true; }
    switch (input) {
        case DEPOSIT:
        case WITHDRAWAL:
        case TRANSFER:
            return true;
    }
    return false;
}

const EMAIL_PATTERN = '[^@\\s]+@[^@\\s\\.]+\\.[^@\\.\\s]+';
const ATM_NAME_PATTERN = '[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*';
const ACCOUNT_NAME_PATTERN = '[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*';


// CUSTOMER
// email is the id
var newCustomerSchema = {
    type: 'object',
    required: [ "lastName", "firstName", "email" ],
    properties: {
        email: {
            type: 'string',
            pattern: `^${EMAIL_PATTERN}$`,
        },
        lastName: {
            type: 'string',
            maxLength: 50,
        },
        firstName: {
            type: 'string',
            maxLength: 50,
        },
    },
    additionalProperties: false,
};

// when updating, email is in request path and cannot be changed
var updateCustomerSchema = {
    type: 'object',
    properties: {
        lastName: {
            type: 'string',
            maxLength: 50,
        },
        firstName: {
            type: 'string',
            maxLength: 50,
        },
    },
    additionalProperties: false,
};


// ATM
// name is the id
const newAtmSchema = {
    type: 'object',
    required: [ "name", "latitude", "longitude" ],
    properties: {
        name: {
            type: 'string',
            maxLength: 50,
            pattern: `^${ATM_NAME_PATTERN}$`,
        },
        description: {
            type: 'string',
            maxLength: 200,
        },
        latitude: {
            type: 'number',
            maxValue: 90,
            minValue: -90,
        },
        longitude: {
            type: 'number',
            maxValue: 180,
            minValue: -180,
        },
    },
    additionalProperties: false,
};

// when updating, name is in request path and cannot be changed
const updateAtmSchema = {
    type: 'object',
    properties: {
        description: {
            type: 'string',
            maxLength: 200,
        },
        latitude: {
            type: 'number',
            maxValue: 90,
            minValue: -90,
        },
        longitude: {
            type: 'number',
            maxValue: 180,
            minValue: -180,
        },
    },
    additionalProperties: false,
};

// ACCOUNT
// customers/{customerEmail}/accounts/{name} is the document path, customerEmail comes from request path
// balance defaults to 0, overdraftAllowed defaults to false
var newAccountSchema = {
    type: 'object',
    required: [ "name" ],
    properties: {
        name: {
            type: 'string',
            maxLength: 50,
            pattern: `^${ACCOUNT_NAME_PATTERN}$`,
        },
        balance: {
            type: 'number',
        },
        overdraftAllowed: {
            type: 'boolean',
        },
    },
    additionalProperties: false,
};
// cannot update accounts via API
// balance is updated via transaction

// TRANSACTION
// autogenerated id, success flag is added to the transaction when saving
// success is false if transaction would overdraw the account
// transactions cannot be modified
// account and toAccount are paths (customers/{customerEmail}/accounts/{accountId})
// transaction type of DEPOSIT/WITHDRAWAL do not have toAccount
var newTransactionSchema = {
    type: 'object',
    required: [ "customerEmail", "accountName", "transactionType", "amount" ],
    properties: {
        customerEmail: {
            type: 'string',
            pattern: `^${EMAIL_PATTERN}$`,
        },
        accountName: {
            type: 'string',
            pattern: `^${ACCOUNT_NAME_PATTERN}$`,
        },
        transactionType: {
            type: 'string',
        },
        amount: {
            type: 'number',
        },
        toCustomerEmail: {
            type: 'string',
            pattern: `^${EMAIL_PATTERN}$`,
        },
        toAccountName: {
            type: 'string',
            pattern: `^${ACCOUNT_NAME_PATTERN}$`,
        },
    },
    additionalProperties: false,
};
// cannot update transactions

const db = admin.firestore();
const backendVer = "1.0.0";

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`SimpleBank Rest API listening on port ${port}`);
});

app.get('/_status', async (req, res) => {
    res.json({serviceName: "simplebank-rest", status: 'API up', ver: backendVer});
});

async function getByPath(path, res) {
    var docRef = db.doc(path);
    var doc = await docRef.get();
    if (!doc.exists) {
        return res.json({});
    }
    else {
        return res.json(doc.data());
    }
}

async function getAllByPath(path, res) {
    var snapshot = await db.collection(path).get();
    var docArray = [];
    snapshot.forEach(doc => {
        docArray.push(doc.data());
    });
    return res.json(docArray);
}

app.get('/customers/:id', async (req, res) => {
    const path = `customers/${req.params.id}`;
    await getByPath(path, res);
});

app.get('/customers', async (req, res) => {
    const path = `customers`;
    await getAllByPath(path, res);
});

app.post('/customers', validate({body: newCustomerSchema}), async (req, res) => {
    // validate verifies format of email (id), firstName, and lastName
    const path = `customers/${req.body.email}`;
    const docRef = db.doc(path);
    var data = {
        email: req.body.email,
        lastName: req.body.lastName,
        firstName: req.body.firstName
    };
    // create if does not exist
    await docRef.create(data).then((result) => {
        // success
        return res.json(data);
    }).catch((err) => {
        console.log(err);
        // failure
        res.status(400);

        var responseData = {
           statusText: 'AlreadyExists',
           messages: [ "Customer with specified email already exists." ]
        };

        return res.json(responseData);
    });
});

app.put('/customers/:id', validate({body: updateCustomerSchema}), async (req, res) => {
    // validate verifies format of firstName and lastName
    if (!req.body.lastName && !req.body.firstName) {
        // something has to be updated
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "firstName or lastName must be updated." ]
        };
        return res.json()
    }

    const path = `customers/${req.params.id}`;
    const docRef = db.doc(path);
    await docRef.update(data).then((result) => {
        // success
        return res.json(data);
    }).catch((err) => {
        // failure
        res.status(404);

        var responseData = {
           statusText: 'NotFound',
           messages: [ "Customer with specified email does not exist." ]
        };

        return res.json(responseData);
    });
});

app.get('/atms/:id', async (req, res) => {
    const path = `atms/${req.params.id}`;
    await getByPath(path, res);
});

app.get('/atms', async (req, res) => {
    const path = `atms`;
    await getAllByPath(path, res);
});

app.post('/atms', validate({body: newAtmSchema}), async (req, res) => {
    // validate verifies format of name, description, latitude, longitude
    const path = `atms/${req.body.name}`;
    const docRef = db.doc(path);
    var data = {
        name: req.body.name,
        description: req.body.description ? req.body.description : '',
        latitude: req.body.latitude,
        longitude: req.body.longitude
    };
    // create if does not exist
    await docRef.create(data).then((result) => {
        // success
        return res.json(data);
    }).catch((err) => {
        // failure
        res.status(400);

        var responseData = {
           statusText: 'AlreadyExists',
           messages: [ "ATM with name already exists." ]
        };

        return res.json(responseData);
    });
});

app.put('/atms/:id', validate({body: updateAtmSchema}), async (req, res) => {
    // validate verifies format of description, latitude, and longitude
    if (!req.body.description && !req.body.latitude && !req.body.longitude) {
        // something has to be updated
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "description, latitude, or longitude must be updated." ]
        };
        return res.json()
    }

    const path = `atms/${req.params.id}`;
    const docRef = db.doc(path);
    await docRef.update(data).then((result) => {
        // success
        return res.json(data);
    }).catch((err) => {
        // failure
        res.status(404);

        var responseData = {
           statusText: 'NotFound',
           messages: [ "ATM with specified name does not exist." ]
        };

        return res.json(responseData);
    });
});

app.get('/customers/:customerEmail/accounts/:accountId', async (req, res) => {
    const path = `customers/${req.params.customerEmail}/accounts/${req.params.accountId}`
    await getByPath(path, res);
});

app.get('/customers/:customerEmail/accounts', async (req, res) => {
    const path = `customers/${req.params.customerEmail}/accounts`;
    await getAllByPath(path, res);
});

app.post('/customers/:customerEmail/accounts', validate({body: newAccountSchema}), async (req, res) => {
    // validate verifies format of name and balance
    const customerEmail = req.params.customerEmail;
    const name = req.body.name;
    const balance = req.body.balance ? req.body.balance : 0.00;
    const overdraftAllowed = req.body.overdraftAllowed ? req.body.overdraftAllowed : false;
    console.log(`create account: customerEmail=${customerEmail}, name=${name}, balance=${balance}, overdraftAllowed=${overdraftAllowed}`);

    if (!isValidCurrency(balance) || balance < 0) {
        // invalid currency
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "When creating an account, the balance must be a nonnegative number with a maximum of 2 digits after the decimal point." ]
        };
        return res.json()
    }

    const customerPath = `customers/${customerEmail}`;
    const customerRef = db.doc(customerPath);

    const accountPath = `customers/${customerEmail}/accounts/${name}`;
    const accountRef = db.doc(accountPath);


    try {
        await db.runTransaction(async (t) => {
            // check customer
            const customerDoc = await t.get(customerRef);
            if (customerDoc.exists) {
                console.log(`customer ${customerPath} exists, will add account ${accountPath}`);
                var data = {
                    customerEmail: customerEmail,
                    name: name,
                    balance: balance,
                    overdraftAllowed: overdraftAllowed,
                };

                await accountRef.create(data).then((result) => {
                    return res.json(data);
                }).catch((err) => {
                    res.status(400);

                    var responseData = {
                        statusText: 'AlreadyExists',
                        messages: [ "Account for customer with name already exists." ]
                    };

                    return res.json(responseData);
                });

            } else {
                // can only create an account for a customer who exists
                res.status(400);
                var responseData = {
                    statusText: 'InvalidCustomer',
                    messages: [ "Customer with specified id does not exist." ]
                };
                return res.json(responseData)
            }
        });
    } catch (e) {
        // transaction failure
        res.status(500);

        var responseData = {
            statusText: 'ServerError',
            messages: [ "Failed when creating account." ]
        };

        return res.json(responseData);
    }
});

app.get('/transactions/:id', async (req, res) => {
    const path = `transactions/${req.params.id}`;
    await getByPath(path, res);
});

app.get('/transactions', async (req, res) => {
    const path = `transactions`;
    await getAllByPath(path, res);
});

app.post('/transactions', validate({body: newTransactionSchema}), async (req, res) => {
    // validate verifies format of customerEmail, accountName, transactionType, amount, toCustomerEmail, toAccountName
    const customerEmail = req.body.customerEmail;
    const accountName = req.body.accountName;
    const transactionType = req.body.transactionType;
    const amount = req.body.amount;
    const toAccount = req.body.toAccount;
    const toCustomerEmail = req.body.toCustomerEmail;
    const toAccountName = req.body.toAccountName;
    console.log(`create transaction: customerEmail=${customerEmail}, accountName=${accountName}, transactionType=${transactionType}, amount=${amount}, toCustomerEmail=${toCustomerEmail}, toAccountName=${toAccountName}`);

    if (!isValidTransactionType(transactionType)) {
        // invalid transactionType
        console.log(`invalid transaction type: ${transactionType}`);
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ `transactionType must be ${DEPOSIT}, ${WITHDRAWAL}, or ${TRANSFER}.` ]
        };
        return res.json(responseData);
    }
    if (!isValidCurrency(amount) || amount <= 0) {
        console.log(`invalid amount: ${amount}`);
        // invalid currency
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "amount must be a positive number with a maximum of 2 digits after the decimal point." ]
        };
        return res.json(responseData);
    }
    if (transactionType === TRANSFER && (!toCustomerEmail || !toAccountName)) {
        console.log(`transfer must have customerEmail and toAccountName`);
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "toCustomerEmail, toAccountName must be set when doing a transfer." ]
        };
        return res.json(responseData);
    }
    if (transactionType !== TRANSFER && (toCustomerEmail || toAccountName)) {
        console.log(`non-transfer must not have toCustomerEmail or toAccountName`);
        res.status(400);
        var responseData = {
           statusText: 'InvalidData',
           messages: [ "toCustomerEmail, toAccountName must not be set unless doing a transfer." ]
        };
        return res.json(responseData);
    }

    const transactionDocRef = db.collection('transactions').doc();
    const transactionDocId = transactionDocRef.id;

    // save initial transaction, success will be overwritten
    var transactionData = {
        id: transactionDocId,
        customerEmail: customerEmail,
        accountName: accountName,
        transactionType: transactionType,
        amount: amount,
        success: false,
    };
    if (toAccount) {
        transactionData.toAccountName = toAccountName;
        transactionData.toCustomerEmail = toCustomerEmail;
    }
    console.log(`Creating initial transaction ${transactionDocId}: transactionData=${JSON.stringify(transactionData)}`);
    await transactionDocRef.create(transactionData).then((result) => {
        // success
    }).catch((err) => {
        // failure
        res.status(400);

        var responseData = {
           statusText: 'DataFailure',
           messages: [ "Failed to create transaction record." ]
        };

        return res.json(responseData);
    });

    console.log(`Starting firestore transaction`);
    try {
        await db.runTransaction(async (t) => {
            // check account
            const accountPath = `customers/${customerEmail}/accounts/${accountName}`;
            const accountRef = db.doc(accountPath);
            console.log(`getting account ${accountPath}`);
            const accountDoc = await t.get(accountRef);
            if (!accountDoc.exists) {
                console.log(`account ${accountPath} does not exist`);
                // account must exist
                t.update(transactionDocRef, { failureMessage: "Source account does not exist." });
                res.status(400);
                var responseData = {
                    statusText: 'InvalidAccount',
                    messages: [ "Specified account does not exist." ]
                };
                return res.json(responseData);
            }
            var accountData = accountDoc.data();
            console.log(`accountData=${JSON.stringify(accountData)}`);
            var accountBalance = accountData.balance;
            var overdraftAllowed = accountData.overdraftAllowed;
            console.log(`accountData: balance=${accountBalance}, overdraftAllowed=${overdraftAllowed}`);

            var toAccountRef = null;
            var toAccountDoc = null;
            var toAccountData = null;
            if (transactionType === TRANSFER) {
                // to account is required
                const toAccountPath = `customers/${toCustomerEmail}/accounts/${toAccountName}`;
                toAccountRef = db.doc(toAccountPath);
                toAccountDoc = await t.get(toAccountRef);
                if (!toAccountDoc.exists) {
                    // toAccount must exist
                    t.update(transactionDocRef, { failureMessage: "Destination account does not exist." });
                    res.status(400);
                    var responseData = {
                        statusText: 'InvalidAccount',
                        messages: [ "Specified toAccount does not exist." ]
                    };
                    return res.json(responseData);
                }
                toAccountData = toAccountDoc.data();
            }

            // get transaction doc (GET required for a firestore transaction)
            const transactionDoc = await t.get(transactionDocRef);
            var transactionDocData = transactionDoc.data();

            var balanceChange;
            if (transactionType === TRANSFER || transactionType === WITHDRAWAL) {
                balanceChange = -amount;

                // check for overdraft
                if (!overdraftAllowed && amount > accountBalance) {
                    // would be overdraft
                    t.update(transactionDocRef, { failureMessage: "Insufficient funds." });
                    res.status(400);
                    var responseData = {
                        statusText: 'InsufficientFunds',
                        messages: [ "Transaction amount is greater than available funds, transaction is rejected." ]
                    };
                    return res.json(responseData);
                }

                t.update(accountRef, {
                    balance: admin.firestore.FieldValue.increment(balanceChange)
                });
                if (transactionType === TRANSFER) {
                    t.update(toAccountRef, {
                        balance: admin.firestore.FieldValue.increment(-balanceChange)
                    });
                }
            } else { // DEPOSIT
                balanceChange = amount;
                console.log(`depositing ${amount} into account ${accountPath}`);

                t.update(accountRef, {
                    balance: admin.firestore.FieldValue.increment(balanceChange)
                });
                console.log(`deposited ${amount} into account ${accountPath}`);
            }

            transactionDocData.success = true;
            console.log(`updating transaction ${transactionDocRef.path} with success`);
            t.update(transactionDocRef, { success: true });
            return res.json(transactionDocData);
        });
    } catch (e) {
        // transaction failure
        res.status(500);

        var responseData = {
            statusText: 'TransactionFailure',
            messages: [ "Failure while creating transaction." ]
        };

        return res.json(responseData);
    }
});

app.use(function(err, req, res, next) {

    var responseData;

    if (err.name === 'JsonSchemaValidation') {
        console.log(err.message);

        res.status(400);

        responseData = {
           errorReason: 'ValidationFailure',
           messages: err.validations.body[0].messages  // All of your validation information
        };

        res.json(responseData);

    } else {
        // pass error to next error middleware handler
        next(err);
    }

});